Add Private Media feature: attachments private by default#458
Open
mikelittle wants to merge 15 commits intomasterfrom
Open
Add Private Media feature: attachments private by default#458mikelittle wants to merge 15 commits intomasterfrom
mikelittle wants to merge 15 commits intomasterfrom
Conversation
Implement the Private Media feature which makes uploaded media attachments private by default. Attachments only become publicly accessible when used in published content, marked as a site icon, flagged as legacy, or manually overridden via the UI/CLI. Key components: - Visibility logic with priority-based public/private determination - Post lifecycle hooks to track publish/unpublish transitions - Content parser to extract attachment references from block content - AWS signing parameter sanitisation on save - Signed URL support for draft/preview contexts - Query compatibility layer (always active) for private post_status - map_meta_cap filter so authors/editors can access private attachments - Media library UI: row actions, bulk actions, modal visibility dropdown - WP-CLI commands: migrate, set-visibility, fix-attachments - 68 integration tests with S3 ACL mocking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a "Visibility" column to the media library list table showing Private/Public status with forced override indicators. Add acceptance tests for the media library UI: upload defaults to private, Make Public and Make Private row actions, and Remove Override action. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add docs/private-media.md explaining the feature from a user perspective: how uploads are private by default, how they become public when content is published, how to manage visibility via quick actions, bulk actions and the media editor sidebar, and configuration options for developers. Includes screenshots of the media library visibility column and row actions, with placeholders for additional screenshots to be added manually (bulk confirmation, modal sidebar, post actions, success notice). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
5 tasks
1. Add per-request static cache for attachment privacy checks to avoid repeated DB lookups when S3 Uploads calls the filter for every URL of every image size (~200 calls per media library page load). 2. Route signed image URLs through tachyon_url() in REST content.raw so X-Amz-* params get bundled into a presign query parameter. Without this, the browser hits CloudFront directly with S3 signing params which it cannot validate (host mismatch), resulting in 404. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Normalize HTML-encoded ampersands (&) before parsing query strings - Restore original separator style after filtering AWS parameters - Add tests for HTML-encoded ampersands in URLs with single and multiple non-AWS params
`set_attachment_visibility()` now uses a direct `$wpdb->update()` + `clean_post_cache()` instead of `wp_update_post()`. This avoids triggering nested hook cascades (image srcset generation, etc.) that cause the OOM error when called from within the parent post's transition_post_status handler. The S3 ACL update and CDN cache purge still run normally via their own calls.
Two root causes prevented attachments from transitioning to public on publish and AWS params from being stripped from stored content: 1. wp_insert_post_data receives slashed content (\" instead of "), so the sanitisation regex never matched src attributes. Fixed by wrapping with wp_unslash()/wp_slash(). 2. HM\Media\Cropper's filter_attachment_meta_data uses a static cache on wp_get_attachment_metadata that doesn't invalidate when we update metadata. After add_post_reference() saved the used_in_published_post key, the subsequent check_attachment_is_public() read returned stale cached data without our key. Fixed by passing $unfiltered=true to all wp_get_attachment_metadata() calls in our visibility functions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On the Altis platform, Tachyon URLs omit the uploads/ prefix (e.g. /tachyon/2026/03/img.jpg instead of /tachyon/uploads/2026/03/img.jpg). The regex only matched the uploads/ variant, so clean_url returned the Tachyon URL unchanged. This caused replace_private_urls() to fail to sign URLs for previews and REST responses after sanitisation stripped the original presign params. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
replace_private_urls() was passing the content-parsed URL (Tachyon or canonical WordPress path) to add_s3_signed_params_to_attachment_url(), but S3 Uploads' get_s3_location_for_url() can only resolve S3 bucket URLs or wp_upload_dir() base URLs. The content-parsed URL didn't match either, so signing silently failed and previews showed broken images. Now uses wp_get_attachment_url() (which returns the S3 URL) with query params stripped, so the S3 location can be resolved and signing works. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Logs attachment discovery, signing resolution, and str_replace results to trace why preview signing isn't working on the deployed server. To be removed after debugging. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
tachyon_url() on an already-signed URL produced a malformed URL with two '?' characters (e.g. ?presign=...?resize=1920,1285). Now we call tachyon_url() on the unsigned base URL first (to get proper sizing params), then manually append the S3 signing params as a presign query parameter with correct & separator. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tachyon's filter_the_content runs at priority 999999 on the_content, rewriting image URLs and adding resize/fit params. Our preview signing was at priority 999 (before Tachyon), so Tachyon stripped the presign params we added. Now runs at priority 1000000 (after Tachyon). When the content URL is already a Tachyon URL (with resize params from Tachyon), we use it as the base and append presign via add_query_arg, preserving both the resize params and the S3 signing params. In REST API context (where Tachyon hasn't processed the content), we build the Tachyon URL first via tachyon_url() then append presign. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Altis\Media\Private_Medianamespace (not extending the existingissue-162-default-private-uploadsbranch)Key components
transition_post_statusandsave_postto track publish/unpublish transitions and detect removed attachmentspre_get_postsfilter addspublishandprivateto attachment queries (always active, even when feature disabled)map_meta_capfilter grantsread_postfor private attachments to users withupload_filescapabilitymigrate,set-visibility,fix-attachmentscommands with--dry-runsupportdocs/private-media.mdwith screenshots and configuration guideFiles
inc/private_media/assets/load.php,inc/namespace.php,composer.json)Test plan
🤖 Generated with Claude Code